fix(skills,stack,scripts): shipped decrypt guidance, and a sound package-path linter (#772 review findings 14, 15) - #789
Conversation
🦋 Changeset detectedLatest commit: 0585f4d The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
freshtonic
left a comment
There was a problem hiding this comment.
Approve.
All three parts check out. Confirmed the typed client genuinely returns a chainable AuditableDecryptModelOperation with .audit()/.withLockContext(), so the corrected decrypt-chaining docs now match the code (the old "plain Promise" wording was the bug), and the WASM-entry statement was correctly left intact. The package-path linter's readdirSync → git ls-files change is the right soundness fix, and protectOps no longer appears in any shipped surface. Changeset targeting is correct (stash patch + @cipherstash/stack patch), and the PR also repairs a real contradiction between two unreleased changesets.
Non-blocking:
- The highest-value fix —
livePackagesfromgit ls-files— has no dedicated regression test. It's awkward to test (needs a tracked-vs-untracked dir fixture) but achievable; without it a future revert toreaddirSyncwould leave every test green. setup-prompt.ts:283now points atcreateEncryptionOperators, but this prompt sets up the v3 flow, where the Drizzle factory iscreateEncryptionOperatorsV3. Not wrong the wayprotectOpswas (the v2 name is a real export), just off-version — considercreateEncryptionOperatorsV3or keeping it generic.
freshtonic
left a comment
There was a problem hiding this comment.
Reviewed the decrypt-guidance corrections and the package-path linter, verifying each claim against source.
Approving. This is a precise, well-scoped remediation.
Verified
- The corrected decrypt-chaining docs (skill +
packages/stack/README.md) now match the real typed-client API:decryptModel/bulkDecryptModelsreturnAuditableDecryptModelOperation(thenable, carrying both.audit()and.withLockContext()), the optional positionallockContextarg exists, and the "double-bind throws" behaviour is real (mapped-decrypt.ts:86-90). protectOps.eq→ops.eqfromcreateEncryptionOperators(client)is correct on thisremove-v2base (theV3suffix was collapsed to the package root).- The linter fix is genuinely sound: the regex right-anchor kills the sentence-final false positive, uppercase is now checked, and the
readdirSync → git ls-filesswitch closes the real false-negative (reproduced end-to-end — an untrackedpackages/*/distshell is now flagged). pnpm test:scripts→ 93 passed; Biome clean on changed files. Changeset targeting (stashpatch +@cipherstash/stackpatch) is correct per AGENTS.md.
Non-blocking nits
- The highest-value linter fix (tracked-vs-untracked via
git ls-files) has no dedicated regression test, so a future revert toreaddirSyncwould leave the suite green. It's testable (untrackedpackages/<name>/distshell). Matches freshtonic's note. setup-prompt.ts:283—createEncryptionOperators(client)is the Drizzle-specific factory but the prompt also covers Supabase (where operators come via theencryptedSupabasewrapper). It hedges with "see the integration skill", so acceptable; consider phrasing it as Drizzle-specific.- Cosmetic: offender-path rendering emits
../../..for a target outside the repo root (unreachable via the default target list).
Note: freshtonic's suggestion to use createEncryptionOperatorsV3 is stale on this base — that name was de-suffixed; the PR's choice is correct.
… repeats Remediation for the two review rounds on #789, plus what verifying them turned up in the same code. **The linter fix the review asked for twice.** `livePackages` moving from `readdirSync` to `git ls-files` was the highest-value change in the PR and had no test that pinned it. It still doesn't fail under the revert anyone would actually write: keeping the git call and unioning the filesystem back in passes every existing test while fully restoring the false negative. The new fixture builds the discriminating case — a package deleted from git whose gitignored `dist/` and `node_modules/` shells survive on disk — and is the only test that fails under both a naive `readdirSync` revert and that hybrid. The `git status` guard is scoped to the probe, not to `packages/`, so an unrelated uncommitted file doesn't turn it into an assertion about nothing. **A missing target no longer passes in silence.** A linter whose entire job is catching dead paths in configuration skipped dead paths in its own: rename a target and it dropped out of coverage forever, green. Its sibling already exits 2 for a stale allowlist entry. Both now do, and both report a target outside the repo by name instead of as a `../../../../..` chain climbing out of the root — the review's cosmetic nit, which turned out to sit in both files. **Every step that names an integration-specific API now branches.** Step 5 was fixed for this; steps 1, 2 and the read-path step were not. A Prisma Next project was sent at `types.*` / `encryptedTable` — the client `stash schema build` explicitly refuses to scaffold for it — and a plain-Postgres project was pointed three times at "the integration skill" it never gets installed. `encryptQuery` is shown taking the schema objects rather than an object-shorthand that read as three required strings. `queryOperatorGuidance` is a switch with a neutral default rather than an if-chain ending in Drizzle's answer: tsup transpiles without type-checking and this package has no typecheck script, so nothing would have caught a fifth integration inheriting it. One test asserted nothing: it scoped to a `#### Encryption cutover` heading that does not exist, and `substring(-1)` returns the whole document.
… the package-path linter sound
Finding 14 — decryptModel/bulkDecryptModels return an
AuditableDecryptModelOperation: thenable, with .withLockContext() and .audit().
Three sites in skills/stash-encryption and four in packages/stack/README.md
said the opposite ("a plain Promise<Result<...>>", "no .withLockContext() to
chain"), steering agents away from the very .audit() chain the audit-on-decrypt
changeset advertises. The skill contradicted its own reference table, which was
already right — 8b74430 fixed that table and nothing else, despite a commit
message claiming otherwise.
Both files ship: the skill inside the stash tarball and thence into customer
repos via installSkills(), the README inside the @cipherstash/stack tarball.
The equivalent statement about the WASM entry is CORRECT and deliberately
untouched — that client really does return a bare promise.
Also `protectOps.eq` in the setup prompt stash init writes for coding agents.
One occurrence repo-wide, and no such export exists; the real API is
createEncryptionOperators(client), conventionally `ops`.
Finding 15 — the package-path linter had a false positive and a false negative,
both fixed with the fixture-driven self-tests the suite already uses:
- The name capture had no right anchor, so a sentence-final `packages/stack.`
swallowed the period and reported a LIVE package as dead. Uppercase was also
excluded from the class, so a capitalised name was never checked at all.
- livePackages came from readdirSync, i.e. the working tree. Deleting a package
leaves dist/ and node_modules/ behind, so every reference to it kept passing —
the exact case the linter was commissioned to catch, silently unenforced on
any checkout that had built the package. Now derived from `git ls-files`.
Deliberately not "has a package.json": packages/utils has none and is live.
- scripts/ was not scanned, so the linters never checked themselves. Adding it
immediately surfaced a dead allowlist entry in lint-no-hardcoded-runners for
a path that never existed in git history. Self-test fixtures stay exempt —
they must name dead packages.
Severity note for the record: CI was never affected (fresh checkout, caching
disabled), and main carries no required status checks — this was a local
developer papercut plus a real soundness hole, not a broken build.
Finally, resolves two changesets that contradicted each other in the same
release: dynamodb-eql-v3 claimed "EQL v2 tables continue to work unchanged" and
"no existing caller needs to change" while stack-dynamodb-v2-write-removal
announced the v2 encrypt overloads as removed. The code sides with removal;
the claims are now scoped to the decrypt path.
…tch their own rot Follow-up to the #772 review findings, addressing what the review of that review turned up. `stash init`'s setup prompt named `createEncryptionOperators` unconditionally. That symbol is exported by `@cipherstash/stack-drizzle` alone, so a Supabase or Prisma Next project was sent after a package that is not in its dependency tree — the previous `protectOps.eq` was wrong for everyone, this was wrong for three integrations out of four. Step 5 now branches through `queryOperatorGuidance()`, following the `migrationCommands()` pattern already in the file: `ops.eq` for Drizzle, the `encryptedSupabase` wrapper's own filters for Supabase, the `eql*` column operators for Prisma Next, and `client.encryptQuery(...)` for plain Postgres — which is also pointed at `stash-encryption`, since it is the default integration and installs no integration skill, making "see the integration skill" a dangling pointer. The package-path linter reported an untracked-but-present package as "does not exist" — finding 15's false alarm pointed the other way, at a directory sitting right there on disk. The live set now unions `git ls-files --others --exclude-standard`. `--directory` is deliberately not passed: it collapses an all-ignored directory to one entry and would resurrect exactly the `dist/`-and- `node_modules/` shells the linter exists to catch (verified both ways). git failing now exits 2 with an actionable message instead of a raw ENOENT stack trace and exit 1, which was indistinguishable from a genuine lint failure, and an empty live set refuses to run rather than flagging every reference at once. The `scans scripts/ but not its fixtures` test asserted the repo was clean — byte-for-byte what the suite's first test already asserted, and passing whether or not `scripts` was in TARGETS. It now plants an offender in the scanned directory. Verified by mutation: it dies when `scripts` is dropped, as the untracked-package test dies without the union and the git-failure test dies when the exit code is flipped. The runners linter now requires each allowlist entry to exist and to still contain an unexcused `npx` literal. It immediately found a second stale entry the sibling linter structurally cannot see, since it only matches the `packages/<name>` shape: `setup-prompt.ts` has had no `npx` since its switch moved to `utils.ts`. Removed. Also corrects two claims: the test comment saying `packages/drizzle` never existed in git history (it was added speculatively by c671560, became load-bearing 31 minutes later with 9d259e6, and rotted when 413ca39 deleted the package), and the `AnyEncryptedTable` doc comment still promising v3 is "purely additive and no existing caller has to change" — true of decrypt, false of encrypt since the v2 write overloads were removed.
… repeats Remediation for the two review rounds on #789, plus what verifying them turned up in the same code. **The linter fix the review asked for twice.** `livePackages` moving from `readdirSync` to `git ls-files` was the highest-value change in the PR and had no test that pinned it. It still doesn't fail under the revert anyone would actually write: keeping the git call and unioning the filesystem back in passes every existing test while fully restoring the false negative. The new fixture builds the discriminating case — a package deleted from git whose gitignored `dist/` and `node_modules/` shells survive on disk — and is the only test that fails under both a naive `readdirSync` revert and that hybrid. The `git status` guard is scoped to the probe, not to `packages/`, so an unrelated uncommitted file doesn't turn it into an assertion about nothing. **A missing target no longer passes in silence.** A linter whose entire job is catching dead paths in configuration skipped dead paths in its own: rename a target and it dropped out of coverage forever, green. Its sibling already exits 2 for a stale allowlist entry. Both now do, and both report a target outside the repo by name instead of as a `../../../../..` chain climbing out of the root — the review's cosmetic nit, which turned out to sit in both files. **Every step that names an integration-specific API now branches.** Step 5 was fixed for this; steps 1, 2 and the read-path step were not. A Prisma Next project was sent at `types.*` / `encryptedTable` — the client `stash schema build` explicitly refuses to scaffold for it — and a plain-Postgres project was pointed three times at "the integration skill" it never gets installed. `encryptQuery` is shown taking the schema objects rather than an object-shorthand that read as three required strings. `queryOperatorGuidance` is a switch with a neutral default rather than an if-chain ending in Drizzle's answer: tsup transpiles without type-checking and this package has no typecheck script, so nothing would have caught a fifth integration inheriting it. One test asserted nothing: it scoped to a `#### Encryption cutover` heading that does not exist, and `substring(-1)` returns the whole document.
…default `stash plan` writes one of three templates. Two of them — the cutover plan and the `--complete-rollout` plan — described the EQL v2 rename swap as the only way to switch reads: "a single transaction renames `<col>` → `<col>_plaintext` and `<col>_encrypted` → `<col>`", with the steps after it referring to `<col>_plaintext` as though the rename had certainly happened. On EQL v3, which is the default, none of that is true. `stash encrypt cutover` refuses a v3 column outright — "Cut-over is not applicable to EQL v3 columns … there is no rename step" (`encrypt/cutover.ts:119`) — and refuses entirely on a v3-only database, where the `eql_v2_configuration` relation it needs does not exist (`:142`). So on a default install the agent was being asked to draft a plan around a command that will decline to run, and to name a `<col>_plaintext` column that will never exist. The implement prompt in this same file already splits the two correctly. This copies that split into the templates that were missing it. The EQL version is per-column — one database can hold both — so the templates tell the agent to establish it per column (`stash encrypt status`) rather than deciding once for the whole plan; a context-level flag would have been wrong for a mixed database. Also: the "this column is already encrypted" stop-and-ask keyed on the `eql_v2_encrypted` udt alone. v3 columns carry `eql_v3_*` domains, so on the default path the check could never fire. The agent reads the schema itself, so naming both is sufficient here. `introspect.ts:88` has the same v2-only assumption in code (`isEqlEncrypted: row.udt_name === 'eql_v2_encrypted'`). Left alone deliberately: it decides which columns `stash init` pre-selects as already-managed, so widening it changes setup behaviour and wants its own tests rather than riding along here.
`introspectDatabase` marked a column as CipherStash-managed only when its udt
was exactly `eql_v2_encrypted`:
isEqlEncrypted: row.udt_name === 'eql_v2_encrypted'
v3 is the default generation and its columns carry per-domain types —
`eql_v3_text_search`, `eql_v3_integer_ord`, and so on — so on the default path
this was false for every encrypted column. The consequences are all in the
column picker: the table hint never said "N already encrypted", the
pre-selection notice never fired, and each encrypted column was displayed with
its plaintext `dataType` and left unticked. An encrypted column presented as
plaintext invites the user to encrypt it a second time, which is the direction
of wrongness that costs data rather than time.
`packages/wizard` already had the correct predicate, with the reasoning
written down (`isEqlEncryptedDomain`, `wizard-tools.ts:167`). This mirrors it
rather than inventing a second answer, and exports it so it is testable — the
old inline comparison sat inside a function that needs a live pg connection,
which is why nothing covered it.
Deliberately not `classifyEqlDomain` from `@cipherstash/migrate`, despite the
dependency already being present: that answers "which generation authors this"
and returns null for `eql_v2_encrypted`, since v2 is no longer authorable. The
question here is "is this encrypted at all", and v2 answers yes.
The per-column hint was also the hardcoded string `eql_v2_encrypted` for any
encrypted column, which mislabels a v3 column with a domain it does not have.
It now reports the column's actual udt, and the pre-selection notice lists the
domains it found instead of naming one it may not have seen.
Checked the rest of the CLI for the same assumption: every other site either
already handles both generations (`rewrite-migrations.ts` matches
`eql_v2_encrypted|eql_v3_[a-z0-9_]+`, `db-readers.ts`, `backfill.ts`) or is
legitimately v2-only (`cutover.ts`, the installer's CREATE TYPE permission
messages). Introspection was the sole outlier.
e8a1e5c to
0585f4d
Compare
|
Audited the review feedback against the head commit ( 1.
Mutation-tested to confirm it discriminates:
The sibling test at 2. Operator guidance scoped to Drizzle — closed by The hardcoded line you quoted at Pinned by five cases at On the earlier 3. The nit was real. Reproduced at the reviewed commit ( Your parenthetical was right too — unreachable via defaults. Both default target lists are repo-relative with no Please re-check before merge. Head has moved since the approvals: |
Review remediation for #772 — findings 14 and 15, plus the contradictory-changeset item from the cut-for-length list.
Finding 14 — the shipped docs contradict the code
decryptModel/bulkDecryptModelsreturn anAuditableDecryptModelOperation: thenable, carrying.withLockContext()and.audit(). Verified both ways — seven chaining forms compile, and a credential-free runtime test (protect-ffi mocked) passes 7/7.Three sites in
skills/stash-encryption/SKILL.mdand four inpackages/stack/README.mdsaid the opposite ("a plainPromise<Result<...>>", "no.withLockContext()to chain"), steering agents away from the exact.audit()chain the audit-on-decrypt changeset advertises. The skill contradicted its own reference table, which was already correct:8b744309fixed that table and nothing else, despite a commit message claiming it had updated the guidance.Both ship — the skill into customer repos via
installSkills(), the README in the@cipherstash/stacktarball.SKILL.md:483says the same thing about the WASM entry and is correct — that client really does return a bare promise with no lock-context argument. Deliberately untouched.Also:
protectOps.eqin the setup promptstash initwrites for coding agents. One occurrence repo-wide, and no such export exists anywhere; the real API iscreateEncryptionOperators(client), conventionallyops.Finding 15 — the linter had a false positive and a false negative
packages/stack.swallowed the period and reported a live package as dead. Uppercase was excluded from the class too, sopackages/Foowas never checked at all.livePackagescame fromreaddirSync— the working tree. Deleting a package leavesdist/andnode_modules/behind, so every reference to it kept passing. That is precisely what this linter was commissioned to catch, silently unenforced on any checkout that had previously built the package. Now derived fromgit ls-files. Deliberately not "has apackage.json" —packages/utilshas none and is live.scripts/was never scanned, so the linters never checked themselves. Adding it immediately surfaced a deadpackages/drizzleallowlist entry inlint-no-hardcoded-runners.mjsfor a path that never existed in git history. Self-test fixtures stay exempt; they must name dead packages.Severity, for the record: CI was never affected —
actions/checkoutgives a fresh workspace with caching disabled — andmaincarries no required status checks. This was a local-developer papercut plus a real soundness hole, not the broken build the review implies.Verified the false negative is closed by re-creating a stale untracked
packages/protect/distshell: the reference is now flagged where it previously passed.Cut-list — contradictory changesets in one release
.changeset/dynamodb-eql-v3.mdclaimed "EQL v2 tables continue to work unchanged" and "no existing caller needs to change", whilestack-dynamodb-v2-write-removal.mdannounced the v2 encrypt overloads as removed — both unreleased, so both render into the same CHANGELOG. The code sides with removal (dynamodb/types.tsdeclaresTable extends AnyV3Tablewith no v2 overload). The claims are now scoped to the decrypt path, which genuinely is unchanged.Verification
pnpm run test:scripts→ 93 passed (90 + 3 new fixture-driven cases).lint:package-pathsandlint:runnersboth clean. Biome: 0 errors.